diff --git a/assets/src/bundles/webapp/webapp.css b/assets/src/bundles/webapp/webapp.css index 2a6f7793..06954f92 100644 --- a/assets/src/bundles/webapp/webapp.css +++ b/assets/src/bundles/webapp/webapp.css @@ -1,705 +1,703 @@ /** * Copyright (C) 2018-2021 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ html { height: 100%; overflow-x: hidden; scroll-behavior: auto !important; } body { min-height: 100%; margin: 0; position: relative; padding-bottom: 120px; } a:active, a.active { outline: none; } code { background-color: #f9f2f4; } pre code { background-color: transparent; } footer { background-color: #262626; color: #fff; font-size: 0.8rem; position: absolute; bottom: 0; width: 100%; - padding-top: 20px; - padding-bottom: 20px; + padding-top: 10px; + padding-bottom: 10px; } footer a, footer a:visited, footer a:hover { color: #fecd1b; } footer a:hover { text-decoration: underline; } .link-color { color: #fecd1b; } pre { background-color: #f5f5f5; border: 1px solid #ccc; border-radius: 4px; padding: 9.5px; font-size: 0.8rem; } .btn.active { background-color: #e7e7e7; } .card { margin-bottom: 5px !important; overflow-x: auto; } .navbar-brand { padding: 5px; margin-right: 0; } .table { margin-bottom: 0; } .swh-table thead { background-color: #f2f4f5; border-top: 1px solid rgba(0, 0, 0, 0.2); font-weight: normal; } .swh-table-striped th { border-top: none; } .swh-table-striped tbody tr:nth-child(even) { background-color: #f2f4f5; } .swh-table-striped tbody tr:nth-child(odd) { background-color: #fff; } .swh-web-app-link a { text-decoration: none; border: none; } .swh-web-app-link:hover { background-color: #efeff2; } .table > thead > tr > th { border-top: none; border-bottom: 1px solid #e20026; } .table > tbody > tr > td { border-style: none; } .sitename .first-word, .sitename .second-word { color: rgba(0, 0, 0, 0.75); font-weight: normal; font-size: 1.2rem; } .sitename .first-word { font-family: 'Alegreya Sans', sans-serif; } .sitename .second-word { font-family: 'Alegreya', serif; } .swh-counter { font-size: 150%; } @media (max-width: 600px) { .swh-counter-container { margin-top: 1rem; } } .swh-http-error { margin: 0 auto; text-align: center; } .swh-http-error-head { color: #2d353c; font-size: 30px; } .swh-http-error-code { bottom: 60%; color: #2d353c; font-size: 96px; line-height: 80px; margin-bottom: 10px !important; } .swh-http-error-desc { font-size: 12px; color: #647788; text-align: center; } .swh-http-error-desc pre { display: inline-block; text-align: left; max-width: 800px; white-space: pre-wrap; } .swh-list-unstyled { list-style: none; } .popover { max-width: 97%; z-index: 40000; } .modal { text-align: center; padding: 0 !important; z-index: 50000; } .modal::before { content: ''; display: inline-block; height: 100%; vertical-align: middle; margin-right: -4px; } .modal-dialog { display: inline-block; text-align: left; vertical-align: middle; } .dropdown-submenu { position: relative; } .dropdown-submenu .dropdown-menu { top: 0; left: -100%; margin-top: -5px; margin-left: -2px; } .dropdown-item:hover, .dropdown-item:focus { background-color: rgba(0, 0, 0, 0.1); } a.dropdown-left::before { content: "\f035e"; font-family: 'Material Design Icons'; display: block; width: 20px; height: 20px; float: left; margin-left: 0; } #swh-navbar { border-top-style: none; border-left-style: none; border-right-style: none; border-bottom-style: solid; border-bottom-width: 5px; border-image: linear-gradient(to right, rgb(226, 0, 38) 0%, rgb(254, 205, 27) 100%) 1 1 1 1; width: 100%; padding: 5px; margin-bottom: 10px; margin-top: 30px; justify-content: normal; flex-wrap: nowrap; height: 72px; overflow: hidden; } #back-to-top { display: none; position: fixed; bottom: 30px; right: 30px; z-index: 10; } #back-to-top a img { display: block; width: 32px; height: 32px; background-size: 32px 32px; text-indent: -999px; overflow: hidden; } .swh-top-bar { direction: ltr; height: 30px; position: fixed; top: 0; left: 0; width: 100%; z-index: 99999; background-color: #262626; color: #fff; text-align: center; font-size: 14px; } .swh-top-bar ul { margin-top: 4px; padding-left: 0; white-space: nowrap; } .swh-top-bar li { display: inline-block; margin-left: 10px; margin-right: 10px; } .swh-top-bar a, .swh-top-bar a:visited { color: white; } .swh-top-bar a.swh-current-site, .swh-top-bar a.swh-current-site:visited { color: #fecd1b; } .swh-position-left { position: absolute; left: 0; } .swh-position-right { position: absolute; right: 0; } .swh-background-gray { background: #efeff2; } .swh-donate-link { border: 1px solid #fecd1b; background-color: #e20026; color: white !important; padding: 3px; border-radius: 3px; } .swh-navbar-content h4 { padding-top: 7px; } .swh-navbar-content .bread-crumbs { display: block; margin-left: -40px; } .swh-navbar-content .bread-crumbs li.bc-no-root { padding-top: 7px; } .main-sidebar { margin-top: 30px; } .content-wrapper { background: none; } .brand-image { max-height: 40px; } .brand-link { padding-top: 18.5px; padding-bottom: 18px; padding-left: 4px; border-bottom: 5px solid #e20026 !important; } .navbar-header a, ul.dropdown-menu a, ul.navbar-nav a, ul.nav-sidebar a { border-bottom-style: none; color: #323232; } .swh-sidebar .nav-link.active { color: #323232 !important; background-color: #e7e7e7 !important; } .nav-tabs .nav-link.active { border-top: 3px solid #e20026; } .swh-image-error { width: 80px; height: auto; } @media (max-width: 600px) { .card { min-width: 80%; } .swh-image-error { width: 40px; height: auto; } .swh-donate-link { display: none; } } .form-check-label { padding-top: 4px; } .swhid { white-space: pre-wrap; } .swhid .swhid-option { display: inline-block; margin-right: 5px; line-height: 1rem; } .nav-pills .nav-link:not(.active):hover { color: rgba(0, 0, 0, 0.55); } .swh-heading-color { color: #e20026 !important; } .sidebar-mini.sidebar-collapse .main-sidebar:hover { width: 4.6rem; } .sidebar-mini.sidebar-collapse .main-sidebar:hover .user-panel > .info, .sidebar-mini.sidebar-collapse .main-sidebar:hover .nav-sidebar .nav-link p, .sidebar-mini.sidebar-collapse .main-sidebar:hover .brand-text { visibility: hidden !important; } .sidebar .nav-link p, .main-sidebar .brand-text, .sidebar .user-panel .info { transition: none; } .sidebar-mini.sidebar-mini.sidebar-collapse .sidebar { padding-right: 0; } .swh-words-logo { position: absolute; top: 0; left: 0; width: 73px; height: 73px; text-align: center; font-size: 10pt; color: rgba(0, 0, 0, 0.75); } .swh-words-logo:hover { text-decoration: none; } .swh-words-logo-swh { line-height: 1; padding-top: 13px; visibility: hidden; } hr.swh-faded-line { border: 0; height: 1px; background-image: linear-gradient(to left, #f0f0f0, #8c8b8b, #f0f0f0); } /* Ensure that section title with link is colored like standard section title */ .swh-readme h1 a, .swh-readme h2 a, .swh-readme h3 a, .swh-readme h4 a, .swh-readme h5 a, .swh-readme h6 a { color: #e20026; } /* Make list compact in reStructuredText rendering */ .swh-rst li p { margin-bottom: 0; } .swh-readme-txt pre { background: none; border: none; } .swh-coverage-col { padding-left: 10px; padding-right: 10px; } .swh-coverage { height: calc(65px + 1em); padding-top: 0.3rem; border: none; } .swh-coverage a { text-decoration: none; } .swh-coverage-logo { display: block; width: 100%; height: 50px; margin-left: auto; margin-right: auto; object-fit: contain; /* polyfill for old browsers, see https://github.com/bfred-it/object-fit-images */ font-family: 'object-fit: contain;'; } .swh-coverage-list { width: 100%; height: 320px; border: none; } tr.swh-tr-hover-highlight:hover td { background: #ededed; } tr.swh-api-doc-route a { text-decoration: none; } .swh-apidoc .col { margin: 10px; } .swh-apidoc .swh-rst blockquote { border: 0; margin: 0; padding: 0; } a.toggle-col { text-decoration: none; } a.toggle-col.col-hidden { text-decoration: line-through; } .admonition.warning { background: #fcf8e3; border: 1px solid #faebcc; padding: 15px; border-radius: 4px; } .admonition.warning p { margin-bottom: 0; } .admonition.warning .first { font-size: 1.5rem; } .swh-popover { max-height: 50vh; overflow-y: auto; overflow-x: auto; padding: 0; } @media screen and (min-width: 768px) { .swh-popover { max-width: 50vw; } } .swh-popover pre { white-space: pre-wrap; margin-bottom: 0; } .d3-wrapper { position: relative; height: 0; width: 100%; padding: 0; /* padding-bottom will be overwritten by JavaScript later */ padding-bottom: 100%; } .d3-wrapper > svg { position: absolute; height: 100%; width: 100%; left: 0; top: 0; } div.d3-tooltip { position: absolute; text-align: center; width: auto; height: auto; padding: 2px; font: 12px sans-serif; background: white; border: 1px solid black; border-radius: 4px; pointer-events: none; } .page-link { cursor: pointer; } .wrapper { overflow: hidden; } .swh-badge { padding-bottom: 1rem; cursor: pointer; } .swh-badge-html, .swh-badge-md, .swh-badge-rst { white-space: pre-wrap; } /* Material Design icons alignment tweaks */ .mdi { display: inline-block; } .mdi-camera { transform: translateY(1px); } .mdi-source-commit { transform: translateY(2px); } /* To set icons at a fixed width. Great to use when different icon widths throw off alignment. Courtesy of Font Awesome. */ .mdi-fw { text-align: center; width: 1.25em; } .main-header .nav-link { height: inherit; } .nav-sidebar .nav-header:not(:first-of-type) { padding-top: 1rem; } .nav-sidebar .nav-link { padding-top: 0; padding-bottom: 0; } .nav-sidebar > .nav-item .nav-icon { vertical-align: sub; } .swh-search-icon { line-height: 1rem; vertical-align: middle; } .swh-search-navbar { position: absolute; top: 0.7rem; right: 15rem; z-index: 50000; width: 500px; } .sidebar-collapse .swh-search-navbar { right: 4rem; } .swh-corner-ribbon { width: 200px; background: #fecd1b; color: #e20026; position: absolute; text-align: center; - line-height: 50px; letter-spacing: 1px; box-shadow: 0 0 3px rgba(0, 0, 0, 0.3); top: 55px; right: -50px; left: auto; transform: rotate(45deg); z-index: 2000; } @media screen and (max-width: 600px) { .swh-corner-ribbon { - line-height: 30px; top: 53px; right: -65px; } } .invalid-feedback { font-size: 100%; } diff --git a/cypress/integration/origin-search.spec.js b/cypress/integration/origin-search.spec.js index 48903c4c..d6858e61 100644 --- a/cypress/integration/origin-search.spec.js +++ b/cypress/integration/origin-search.spec.js @@ -1,556 +1,556 @@ /** * Copyright (C) 2019-2021 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ const nonExistentText = 'NoMatchExists'; let origin; let url; function doSearch(searchText, searchInputElt = '#swh-origins-url-patterns') { if (searchText.startsWith('swh:')) { cy.intercept('**/api/1/resolve/**') .as('swhidResolve'); } cy.get(searchInputElt) // to avoid sending too much SWHID validation requests // as cypress insert character one by one when using type .invoke('val', searchText.slice(0, -1)) .type(searchText.slice(-1)) .get('.swh-search-icon') - .click(); + .click({force: true}); if (searchText.startsWith('swh:')) { cy.wait('@swhidResolve'); } } function searchShouldRedirect(searchText, redirectUrl) { doSearch(searchText); cy.location('pathname') .should('equal', redirectUrl); } function searchShouldShowNotFound(searchText, msg) { doSearch(searchText); if (searchText.startsWith('swh:')) { cy.get('.invalid-feedback') .should('be.visible') .and('contain', msg); } } function stubOriginVisitLatestRequests(status = 200, response = {type: 'tar'}, aliasSuffix = '') { cy.intercept({url: '**/visit/latest/**'}, { body: response, statusCode: status }).as(`originVisitLatest${aliasSuffix}`); } describe('Test origin-search', function() { before(function() { origin = this.origin[0]; url = this.Urls.browse_search(); }); beforeEach(function() { cy.visit(url); }); it('should have focus on search form after page load', function() { cy.get('#swh-origins-url-patterns') .should('have.attr', 'autofocus'); // for some reason, autofocus is not honored when running cypress tests // while it is in non controlled browsers // .should('have.focus'); }); it('should show in result when url is searched', function() { cy.get('#swh-origins-url-patterns') .type(origin.url); cy.get('.swh-search-icon') .click(); cy.get('#origin-search-results') .should('be.visible'); cy.contains('tr', origin.url) .should('be.visible') .find('.swh-visit-status') .find('i') .should('have.class', 'mdi-check-bold') .and('have.attr', 'title', 'Software origin has been archived by Software Heritage'); const browseOriginUrl = `${this.Urls.browse_origin()}?origin_url=${encodeURIComponent(origin.url)}`; cy.get('tr a') .should('have.attr', 'href', browseOriginUrl); }); it('should remove origin URL with no archived content', function() { stubOriginVisitLatestRequests(404); cy.get('#swh-origins-url-patterns') .type(origin.url); cy.get('.swh-search-icon') .click(); cy.wait('@originVisitLatest'); cy.get('#origin-search-results') .should('be.visible') .find('tbody tr').should('have.length', 0); stubOriginVisitLatestRequests(200, {}, '2'); cy.get('.swh-search-icon') .click(); cy.wait('@originVisitLatest2'); cy.get('#origin-search-results') .should('be.visible') .find('tbody tr').should('have.length', 0); }); it('should filter origins by visit type', function() { cy.intercept('**/visit/latest/**').as('checkOriginVisits'); cy.get('#swh-origins-url-patterns') .type('http'); for (let visitType of ['git', 'tar']) { cy.get('#swh-search-visit-type') .select(visitType); cy.get('.swh-search-icon') .click(); cy.wait('@checkOriginVisits'); cy.get('#origin-search-results') .should('be.visible'); cy.get('tbody tr td.swh-origin-visit-type').then(elts => { for (let elt of elts) { cy.get(elt).should('have.text', visitType); } }); } }); it('should show not found message when no repo matches', function() { searchShouldShowNotFound(nonExistentText, 'No origins matching the search criteria were found.'); }); it('should add appropriate URL parameters', function() { // Check all three checkboxes and check if // correct url params are added cy.get('#swh-search-origins-with-visit') .check({force: true}) .get('#swh-filter-empty-visits') .check({force: true}) .get('#swh-search-origin-metadata') .check({force: true}) .then(() => { const searchText = origin.url; doSearch(searchText); cy.location('search').then(locationSearch => { const urlParams = new URLSearchParams(locationSearch); const query = urlParams.get('q'); const withVisit = urlParams.has('with_visit'); const withContent = urlParams.has('with_content'); const searchMetadata = urlParams.has('search_metadata'); assert.strictEqual(query, searchText); assert.strictEqual(withVisit, true); assert.strictEqual(withContent, true); assert.strictEqual(searchMetadata, true); }); }); }); it('should search in origin intrinsic metadata', function() { cy.intercept('GET', '**/origin/metadata-search/**').as( 'originMetadataSearch' ); cy.get('#swh-search-origins-with-visit') .check({force: true}) .get('#swh-filter-empty-visits') .check({force: true}) .get('#swh-search-origin-metadata') .check({force: true}) .then(() => { const searchText = 'plugin'; doSearch(searchText); console.log(searchText); cy.wait('@originMetadataSearch').then((req) => { expect(req.response.body[0].metadata.metadata.description).to.equal( 'Line numbering plugin for Highlight.js' // metadata is defined in _TEST_ORIGINS variable in swh/web/tests/data.py ); }); }); }); it('should not send request to the resolve endpoint', function() { cy.intercept(`${this.Urls.api_1_resolve_swhid('').slice(0, -1)}**`) .as('resolveSWHID'); cy.intercept(`${this.Urls.api_1_origin_search(origin.url)}**`) .as('searchOrigin'); cy.get('#swh-origins-url-patterns') .type(origin.url); cy.get('.swh-search-icon') .click(); cy.wait('@searchOrigin'); cy.xhrShouldBeCalled('resolveSWHID', 0); cy.xhrShouldBeCalled('searchOrigin', 1); }); context('Test pagination', function() { it('should not paginate if there are not many results', function() { // Setup search cy.get('#swh-search-origins-with-visit') .uncheck({force: true}) .get('#swh-filter-empty-visits') .uncheck({force: true}) .then(() => { const searchText = 'libtess'; // Get first page of results doSearch(searchText); cy.get('.swh-search-result-entry') .should('have.length', 1); cy.get('.swh-search-result-entry#origin-0 td a') .should('have.text', 'https://github.com/memononen/libtess2'); cy.get('#origins-prev-results-button') .should('have.class', 'disabled'); cy.get('#origins-next-results-button') .should('have.class', 'disabled'); }); }); it('should paginate forward when there are many results', function() { stubOriginVisitLatestRequests(); // Setup search cy.get('#swh-search-origins-with-visit') .uncheck({force: true}) .get('#swh-filter-empty-visits') .uncheck({force: true}) .then(() => { const searchText = 'many.origins'; // Get first page of results doSearch(searchText); cy.wait('@originVisitLatest'); cy.get('.swh-search-result-entry') .should('have.length', 100); cy.get('.swh-search-result-entry#origin-0 td a') .should('have.text', 'https://many.origins/1'); cy.get('.swh-search-result-entry#origin-99 td a') .should('have.text', 'https://many.origins/100'); cy.get('#origins-prev-results-button') .should('have.class', 'disabled'); cy.get('#origins-next-results-button') .should('not.have.class', 'disabled'); // Get second page of results cy.get('#origins-next-results-button a') .click(); cy.wait('@originVisitLatest'); cy.get('.swh-search-result-entry') .should('have.length', 100); cy.get('.swh-search-result-entry#origin-0 td a') .should('have.text', 'https://many.origins/101'); cy.get('.swh-search-result-entry#origin-99 td a') .should('have.text', 'https://many.origins/200'); cy.get('#origins-prev-results-button') .should('not.have.class', 'disabled'); cy.get('#origins-next-results-button') .should('not.have.class', 'disabled'); // Get third (and last) page of results cy.get('#origins-next-results-button a') .click(); cy.wait('@originVisitLatest'); cy.get('.swh-search-result-entry') .should('have.length', 50); cy.get('.swh-search-result-entry#origin-0 td a') .should('have.text', 'https://many.origins/201'); cy.get('.swh-search-result-entry#origin-49 td a') .should('have.text', 'https://many.origins/250'); cy.get('#origins-prev-results-button') .should('not.have.class', 'disabled'); cy.get('#origins-next-results-button') .should('have.class', 'disabled'); }); }); it('should paginate backward from a middle page', function() { stubOriginVisitLatestRequests(); // Setup search cy.get('#swh-search-origins-with-visit') .uncheck({force: true}) .get('#swh-filter-empty-visits') .uncheck({force: true}) .then(() => { const searchText = 'many.origins'; // Get first page of results doSearch(searchText); cy.wait('@originVisitLatest'); cy.get('#origins-prev-results-button') .should('have.class', 'disabled'); cy.get('#origins-next-results-button') .should('not.have.class', 'disabled'); // Get second page of results cy.get('#origins-next-results-button a') .click(); cy.wait('@originVisitLatest'); cy.get('#origins-prev-results-button') .should('not.have.class', 'disabled'); cy.get('#origins-next-results-button') .should('not.have.class', 'disabled'); // Get first page of results again cy.get('#origins-prev-results-button a') .click(); cy.wait('@originVisitLatest'); cy.get('.swh-search-result-entry') .should('have.length', 100); cy.get('.swh-search-result-entry#origin-0 td a') .should('have.text', 'https://many.origins/1'); cy.get('.swh-search-result-entry#origin-99 td a') .should('have.text', 'https://many.origins/100'); cy.get('#origins-prev-results-button') .should('have.class', 'disabled'); cy.get('#origins-next-results-button') .should('not.have.class', 'disabled'); }); }); it('should paginate backward from the last page', function() { stubOriginVisitLatestRequests(); // Setup search cy.get('#swh-search-origins-with-visit') .uncheck({force: true}) .get('#swh-filter-empty-visits') .uncheck({force: true}) .then(() => { const searchText = 'many.origins'; // Get first page of results doSearch(searchText); cy.wait('@originVisitLatest'); cy.get('#origins-prev-results-button') .should('have.class', 'disabled'); cy.get('#origins-next-results-button') .should('not.have.class', 'disabled'); // Get second page of results cy.get('#origins-next-results-button a') .click(); cy.wait('@originVisitLatest'); cy.get('#origins-prev-results-button') .should('not.have.class', 'disabled'); cy.get('#origins-next-results-button') .should('not.have.class', 'disabled'); // Get third (and last) page of results cy.get('#origins-next-results-button a') .click(); cy.get('#origins-prev-results-button') .should('not.have.class', 'disabled'); cy.get('#origins-next-results-button') .should('have.class', 'disabled'); // Get second page of results again cy.get('#origins-prev-results-button a') .click(); cy.wait('@originVisitLatest'); cy.get('.swh-search-result-entry') .should('have.length', 100); cy.get('.swh-search-result-entry#origin-0 td a') .should('have.text', 'https://many.origins/101'); cy.get('.swh-search-result-entry#origin-99 td a') .should('have.text', 'https://many.origins/200'); cy.get('#origins-prev-results-button') .should('not.have.class', 'disabled'); cy.get('#origins-next-results-button') .should('not.have.class', 'disabled'); // Get first page of results again cy.get('#origins-prev-results-button a') .click(); cy.wait('@originVisitLatest'); cy.get('.swh-search-result-entry') .should('have.length', 100); cy.get('.swh-search-result-entry#origin-0 td a') .should('have.text', 'https://many.origins/1'); cy.get('.swh-search-result-entry#origin-99 td a') .should('have.text', 'https://many.origins/100'); cy.get('#origins-prev-results-button') .should('have.class', 'disabled'); cy.get('#origins-next-results-button') .should('not.have.class', 'disabled'); }); }); }); context('Test valid SWHIDs', function() { it('should resolve directory', function() { const redirectUrl = this.Urls.browse_directory(origin.content[0].directory); const swhid = `swh:1:dir:${origin.content[0].directory}`; searchShouldRedirect(swhid, redirectUrl); }); it('should resolve revision', function() { const redirectUrl = this.Urls.browse_revision(origin.revisions[0]); const swhid = `swh:1:rev:${origin.revisions[0]}`; searchShouldRedirect(swhid, redirectUrl); }); it('should resolve snapshot', function() { const redirectUrl = this.Urls.browse_snapshot_directory(origin.snapshot); const swhid = `swh:1:snp:${origin.snapshot}`; searchShouldRedirect(swhid, redirectUrl); }); it('should resolve content', function() { const redirectUrl = this.Urls.browse_content(`sha1_git:${origin.content[0].sha1git}`); const swhid = `swh:1:cnt:${origin.content[0].sha1git}`; searchShouldRedirect(swhid, redirectUrl); }); it('should not send request to the search endpoint', function() { const swhid = `swh:1:rev:${origin.revisions[0]}`; cy.intercept(this.Urls.api_1_resolve_swhid(swhid)) .as('resolveSWHID'); cy.intercept(`${this.Urls.api_1_origin_search('').slice(0, -1)}**`) .as('searchOrigin'); cy.get('#swh-origins-url-patterns') .type(swhid); cy.get('.swh-search-icon') .click(); cy.wait('@resolveSWHID'); cy.xhrShouldBeCalled('resolveSWHID', 1); cy.xhrShouldBeCalled('searchOrigin', 0); }); }); context('Test invalid SWHIDs', function() { it('should show not found for directory', function() { const swhid = `swh:1:dir:${this.unarchivedRepo.rootDirectory}`; const msg = `Directory with sha1_git ${this.unarchivedRepo.rootDirectory} not found`; searchShouldShowNotFound(swhid, msg); }); it('should show not found for snapshot', function() { const swhid = `swh:1:snp:${this.unarchivedRepo.snapshot}`; const msg = `Snapshot with id ${this.unarchivedRepo.snapshot} not found!`; searchShouldShowNotFound(swhid, msg); }); it('should show not found for revision', function() { const swhid = `swh:1:rev:${this.unarchivedRepo.revision}`; const msg = `Revision with sha1_git ${this.unarchivedRepo.revision} not found.`; searchShouldShowNotFound(swhid, msg); }); it('should show not found for content', function() { const swhid = `swh:1:cnt:${this.unarchivedRepo.content[0].sha1git}`; const msg = `Content with sha1_git checksum equals to ${this.unarchivedRepo.content[0].sha1git} not found!`; searchShouldShowNotFound(swhid, msg); }); function checkInvalidSWHIDReport(url, searchInputElt, swhidInput, validationMessagePattern = '') { cy.visit(url); doSearch(swhidInput, searchInputElt); cy.get(searchInputElt) .then($el => $el[0].checkValidity()).should('be.false'); cy.get(searchInputElt) .invoke('prop', 'validationMessage') .should('not.equal', '') .should('contain', validationMessagePattern); } it('should report invalid SWHID in search page input', function() { const swhidInput = `swh:1:cnt:${this.unarchivedRepo.content[0].sha1git};lines=45-60/`; checkInvalidSWHIDReport(this.Urls.browse_search(), '#swh-origins-url-patterns', swhidInput); cy.get('.invalid-feedback') .should('be.visible'); }); it('should report invalid SWHID in top right search input', function() { const swhidInput = `swh:1:cnt:${this.unarchivedRepo.content[0].sha1git};lines=45-60/`; checkInvalidSWHIDReport(this.Urls.browse_help(), '#swh-origins-search-top-input', swhidInput); }); it('should report SWHID with uppercase chars in search page input', function() { const swhidInput = `swh:1:cnt:${this.unarchivedRepo.content[0].sha1git}`.toUpperCase(); checkInvalidSWHIDReport(this.Urls.browse_search(), '#swh-origins-url-patterns', swhidInput, swhidInput.toLowerCase()); cy.get('.invalid-feedback') .should('be.visible'); }); it('should report SWHID with uppercase chars in top right search input', function() { let swhidInput = `swh:1:cnt:${this.unarchivedRepo.content[0].sha1git}`.toUpperCase(); swhidInput += ';lines=45-60/'; checkInvalidSWHIDReport(this.Urls.browse_help(), '#swh-origins-search-top-input', swhidInput.toLowerCase()); }); }); }); diff --git a/swh/web/common/utils.py b/swh/web/common/utils.py index dae3cf4b..1115f970 100644 --- a/swh/web/common/utils.py +++ b/swh/web/common/utils.py @@ -1,351 +1,354 @@ # Copyright (C) 2017-2021 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information from datetime import datetime, timezone import os import re from typing import Any, Dict, Optional from bs4 import BeautifulSoup from docutils.core import publish_parts import docutils.parsers.rst import docutils.utils from docutils.writers.html5_polyglot import HTMLTranslator, Writer from iso8601 import ParseError, parse_date +from pkg_resources import get_distribution from prometheus_client.registry import CollectorRegistry from django.http import HttpRequest, QueryDict from django.urls import reverse as django_reverse from swh.web.common.exc import BadInputExc from swh.web.common.typing import QueryParameters from swh.web.config import ORIGIN_VISIT_TYPES, get_config SWH_WEB_METRICS_REGISTRY = CollectorRegistry(auto_describe=True) swh_object_icons = { "alias": "mdi mdi-star", "branch": "mdi mdi-source-branch", "branches": "mdi mdi-source-branch", "content": "mdi mdi-file-document", "directory": "mdi mdi-folder", "origin": "mdi mdi-source-repository", "person": "mdi mdi-account", "revisions history": "mdi mdi-history", "release": "mdi mdi-tag", "releases": "mdi mdi-tag", "revision": "mdi mdi-rotate-90 mdi-source-commit", "snapshot": "mdi mdi-camera", "visits": "mdi mdi-calendar-month", } def reverse( viewname: str, url_args: Optional[Dict[str, Any]] = None, query_params: Optional[QueryParameters] = None, current_app: Optional[str] = None, urlconf: Optional[str] = None, request: Optional[HttpRequest] = None, ) -> str: """An override of django reverse function supporting query parameters. Args: viewname: the name of the django view from which to compute a url url_args: dictionary of url arguments indexed by their names query_params: dictionary of query parameters to append to the reversed url current_app: the name of the django app tighten to the view urlconf: url configuration module request: build an absolute URI if provided Returns: str: the url of the requested view with processed arguments and query parameters """ if url_args: url_args = {k: v for k, v in url_args.items() if v is not None} url = django_reverse( viewname, urlconf=urlconf, kwargs=url_args, current_app=current_app ) if query_params: query_params = {k: v for k, v in query_params.items() if v is not None} if query_params and len(query_params) > 0: query_dict = QueryDict("", mutable=True) for k in sorted(query_params.keys()): query_dict[k] = query_params[k] url += "?" + query_dict.urlencode(safe="/;:") if request is not None: url = request.build_absolute_uri(url) return url def datetime_to_utc(date): """Returns datetime in UTC without timezone info Args: date (datetime.datetime): input datetime with timezone info Returns: datetime.datetime: datetime in UTC without timezone info """ if date.tzinfo and date.tzinfo != timezone.utc: return date.astimezone(tz=timezone.utc) else: return date def parse_iso8601_date_to_utc(iso_date: str) -> datetime: """Given an ISO 8601 datetime string, parse the result as UTC datetime. Returns: a timezone-aware datetime representing the parsed date Raises: swh.web.common.exc.BadInputExc: provided date does not respect ISO 8601 format Samples: - 2016-01-12 - 2016-01-12T09:19:12+0100 - 2007-01-14T20:34:22Z """ try: date = parse_date(iso_date) return datetime_to_utc(date) except ParseError as e: raise BadInputExc(e) def shorten_path(path): """Shorten the given path: for each hash present, only return the first 8 characters followed by an ellipsis""" sha256_re = r"([0-9a-f]{8})[0-9a-z]{56}" sha1_re = r"([0-9a-f]{8})[0-9a-f]{32}" ret = re.sub(sha256_re, r"\1...", path) return re.sub(sha1_re, r"\1...", ret) def format_utc_iso_date(iso_date, fmt="%d %B %Y, %H:%M UTC"): """Turns a string representation of an ISO 8601 datetime string to UTC and format it into a more human readable one. For instance, from the following input string: '2017-05-04T13:27:13+02:00' the following one is returned: '04 May 2017, 11:27 UTC'. Custom format string may also be provided as parameter Args: iso_date (str): a string representation of an ISO 8601 date fmt (str): optional date formatting string Returns: str: a formatted string representation of the input iso date """ if not iso_date: return iso_date date = parse_iso8601_date_to_utc(iso_date) return date.strftime(fmt) def gen_path_info(path): """Function to generate path data navigation for use with a breadcrumb in the swh web ui. For instance, from a path /folder1/folder2/folder3, it returns the following list:: [{'name': 'folder1', 'path': 'folder1'}, {'name': 'folder2', 'path': 'folder1/folder2'}, {'name': 'folder3', 'path': 'folder1/folder2/folder3'}] Args: path: a filesystem path Returns: list: a list of path data for navigation as illustrated above. """ path_info = [] if path: sub_paths = path.strip("/").split("/") path_from_root = "" for p in sub_paths: path_from_root += "/" + p path_info.append({"name": p, "path": path_from_root.strip("/")}) return path_info def parse_rst(text, report_level=2): """ Parse a reStructuredText string with docutils. Args: text (str): string with reStructuredText markups in it report_level (int): level of docutils report messages to print (1 info 2 warning 3 error 4 severe 5 none) Returns: docutils.nodes.document: a parsed docutils document """ parser = docutils.parsers.rst.Parser() components = (docutils.parsers.rst.Parser,) settings = docutils.frontend.OptionParser( components=components ).get_default_values() settings.report_level = report_level document = docutils.utils.new_document("rst-doc", settings=settings) parser.parse(text, document) return document def get_client_ip(request): """ Return the client IP address from an incoming HTTP request. Args: request (django.http.HttpRequest): the incoming HTTP request Returns: str: The client IP address """ x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") if x_forwarded_for: ip = x_forwarded_for.split(",")[0] else: ip = request.META.get("REMOTE_ADDR") return ip browsers_supported_image_mimes = set( [ "image/gif", "image/png", "image/jpeg", "image/bmp", "image/webp", "image/svg", "image/svg+xml", ] ) def context_processor(request): """ Django context processor used to inject variables in all swh-web templates. """ config = get_config() if ( hasattr(request, "user") and request.user.is_authenticated and not hasattr(request.user, "backend") ): # To avoid django.template.base.VariableDoesNotExist errors # when rendering templates when standard Django user is logged in. request.user.backend = "django.contrib.auth.backends.ModelBackend" site_base_url = request.build_absolute_uri("/") return { "swh_object_icons": swh_object_icons, "available_languages": None, "swh_client_config": config["client_config"], "oidc_enabled": bool(config["keycloak"]["server_url"]), "browsers_supported_image_mimes": browsers_supported_image_mimes, "keycloak": config["keycloak"], "site_base_url": site_base_url, "DJANGO_SETTINGS_MODULE": os.environ["DJANGO_SETTINGS_MODULE"], "status": config["status"], + "swh_web_dev": "localhost" in site_base_url, "swh_web_staging": any( [ server_name in site_base_url for server_name in config["staging_server_names"] ] ), + "swh_web_version": get_distribution("swh.web").version, "visit_types": ORIGIN_VISIT_TYPES, } def resolve_branch_alias( snapshot: Dict[str, Any], branch: Optional[Dict[str, Any]] ) -> Optional[Dict[str, Any]]: """ Resolve branch alias in snapshot content. Args: snapshot: a full snapshot content branch: a branch alias contained in the snapshot Returns: The real snapshot branch that got aliased. """ while branch and branch["target_type"] == "alias": if branch["target"] in snapshot["branches"]: branch = snapshot["branches"][branch["target"]] else: from swh.web.common import archive snp = archive.lookup_snapshot( snapshot["id"], branches_from=branch["target"], branches_count=1 ) if snp and branch["target"] in snp["branches"]: branch = snp["branches"][branch["target"]] else: branch = None return branch class _NoHeaderHTMLTranslator(HTMLTranslator): """ Docutils translator subclass to customize the generation of HTML from reST-formatted docstrings """ def __init__(self, document): super().__init__(document) self.body_prefix = [] self.body_suffix = [] _HTML_WRITER = Writer() _HTML_WRITER.translator_class = _NoHeaderHTMLTranslator def rst_to_html(rst: str) -> str: """ Convert reStructuredText document into HTML. Args: rst: A string containing a reStructuredText document Returns: Body content of the produced HTML conversion. """ settings = { "initial_header_level": 2, } pp = publish_parts(rst, writer=_HTML_WRITER, settings_overrides=settings) return f'<div class="swh-rst">{pp["html_body"]}</div>' def prettify_html(html: str) -> str: """ Prettify an HTML document. Args: html: Input HTML document Returns: The prettified HTML document """ return BeautifulSoup(html, "lxml").prettify() diff --git a/swh/web/templates/layout.html b/swh/web/templates/layout.html index e97007a2..0a27f1f5 100644 --- a/swh/web/templates/layout.html +++ b/swh/web/templates/layout.html @@ -1,278 +1,281 @@ {% comment %} Copyright (C) 2015-2021 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} <!DOCTYPE html> {% load js_reverse %} {% load static %} {% load render_bundle from webpack_loader %} {% load swh_templatetags %} <html lang="en"> <head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <title>{% block title %}{% endblock %}</title> {% render_bundle 'vendors' %} {% render_bundle 'webapp' %} <script> /* @licstart The following is the entire license notice for the JavaScript code in this page. Copyright (C) 2015-2021 The Software Heritage developers This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. @licend The above is the entire license notice for the JavaScript code in this page. */ </script> <script> SWH_CONFIG = {{swh_client_config|jsonify}}; swh.webapp.sentryInit(SWH_CONFIG.sentry_dsn); </script> <script src="{% url 'js_reverse' %}" type="text/javascript"></script> <script> swh.webapp.setSwhObjectIcons({{ swh_object_icons|jsonify }}); </script> {{ request.user.is_authenticated|json_script:"swh_user_logged_in" }} {% block header %}{% endblock %} <link rel="icon" href="{% static 'img/icons/swh-logo-32x32.png' %}" sizes="32x32" /> <link rel="icon" href="{% static 'img/icons/swh-logo-archive-192x192.png' %}" sizes="192x192" /> <link rel="apple-touch-icon-precomposed" href="{% static 'img/icons/swh-logo-archive-180x180.png' %}" /> <link rel="search" type="application/opensearchdescription+xml" title="Software Heritage archive of public source code" href="{% static 'xml/swh-opensearch.xml' %}"> <meta name="msapplication-TileImage" content="{% static 'img/icons/swh-logo-archive-270x270.png' %}" /> {% if "production" in DJANGO_SETTINGS_MODULE %} <!-- Matomo --> <script type="text/javascript"> var _paq = window._paq = window._paq || []; _paq.push(['trackPageView']); (function() { var u="https://piwik.inria.fr/"; _paq.push(['setTrackerUrl', u+'matomo.php']); _paq.push(['setSiteId', '59']); var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; g.type='text/javascript'; g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s); })(); </script> <!-- End Matomo Code --> {% endif %} </head> <body class="hold-transition layout-fixed sidebar-mini"> <a id="top"></a> <div class="wrapper"> <div class="swh-top-bar"> <ul> <li class="swh-position-left"> <div id="swh-full-width-switch-container" class="custom-control custom-switch d-none d-lg-block d-xl-block"> <input type="checkbox" class="custom-control-input" id="swh-full-width-switch" onclick="swh.webapp.fullWidthToggled(event)"> <label class="custom-control-label font-weight-normal" for="swh-full-width-switch">Full width</label> </div> </li> <li> <a href="https://www.softwareheritage.org">Home</a> </li> <li> <a href="https://forge.softwareheritage.org/">Development</a> </li> <li> <a href="https://docs.softwareheritage.org/devel/">Documentation</a> </li> <li> <a class="swh-donate-link" href="https://www.softwareheritage.org/donate">Donate</a> </li> <li class="swh-position-right"> <a href="{{ status.server_url }}" target="_blank" class="swh-current-status mr-3 d-none d-lg-inline-block d-xl-inline-block"> <span id="swh-current-status-description">Operational</span> <i class="swh-current-status-indicator green"></i> </a> {% url 'logout' as logout_url %} {% if user.is_authenticated %} Logged in as {% if 'OIDC' in user.backend %} <a href="{% url 'oidc-profile' %}"><strong>{{ user.username }}</strong></a>, <a href="{% url 'oidc-logout' %}?next_path={% url 'logout' %}?remote_user=1">logout</a> {% else %} <strong>{{ user.username }}</strong>, <a href="{{ logout_url }}">logout</a> {% endif %} {% elif oidc_enabled %} {% if request.path != logout_url %} <a href="{% url 'oidc-login' %}?next_path={{ request.build_absolute_uri }}">login</a> {% else %} <a href="{% url 'oidc-login' %}">login</a> {% endif %} {% else %} {% if request.path != logout_url %} <a href="{% url 'login' %}?next={{ request.build_absolute_uri }}">login</a> {% else %} <a href="{% url 'login' %}">login</a> {% endif %} {% endif %} </li> </ul> </div> <nav class="main-header navbar navbar-expand-lg navbar-light navbar-static-top" id="swh-navbar"> <div class="navbar-header"> <a class="nav-link swh-push-menu" data-widget="pushmenu" data-enable-remember="true" href="#"> <i class="mdi mdi-24px mdi-menu mdi-fw" aria-hidden="true"></i> </a> </div> <div class="navbar" style="width: 94%;"> <div class="swh-navbar-content"> {% block navbar-content %}{% endblock %} {% if request.resolver_match.url_name != 'swh-web-homepage' and request.resolver_match.url_name != 'browse-search' %} <form class="form-horizontal d-none d-md-flex input-group swh-search-navbar needs-validation" id="swh-origins-search-top"> <input class="form-control" placeholder="Enter a SWHID to resolve or keyword(s) to search for in origin URLs" type="text" id="swh-origins-search-top-input" oninput="swh.webapp.validateSWHIDInput(this)" required/> <div class="input-group-append"> <button class="btn btn-primary" type="submit"> <i class="swh-search-icon mdi mdi-24px mdi-magnify" aria-hidden="true"></i> </button> </div> </form> {% endif %} </div> </div> </nav> </div> <aside class="swh-sidebar main-sidebar sidebar-no-expand sidebar-light-primary elevation-4"> <a href="{% url 'swh-web-homepage' %}" class="brand-link"> <img class="brand-image" src="{% static 'img/swh-logo.png' %}"> <div class="brand-text sitename" href="{% url 'swh-web-homepage' %}"> <span class="first-word">Software</span> <span class="second-word">Heritage</span> </div> </a> <a href="/" class="swh-words-logo"> <div class="swh-words-logo-swh"> <span class="first-word">Software</span> <span class="second-word">Heritage</span> </div> <span>Archive</span> </a> <div class="sidebar"> <nav class="mt-2"> <ul class="nav nav-pills nav-sidebar flex-column" data-widget="treeview" role="menu" data-accordion="false"> <li class="nav-header">Features</li> <li class="nav-item swh-search-item" title="Search archived software"> <a href="{% url 'browse-search' %}" class="nav-link swh-search-link"> <i style="color: #e20026;" class="nav-icon mdi mdi-24px mdi-magnify"></i> <p>Search</p> </a> </li> <li class="nav-item swh-vault-item" title="Download archived software from the Vault"> <a href="{% url 'browse-vault' %}" class="nav-link swh-vault-link"> <i style="color: #e20026;" class="nav-icon mdi mdi-24px mdi-download"></i> <p>Downloads</p> </a> </li> <li class="nav-item swh-origin-save-item" title="Request the saving of a software origin into the archive"> <a href="{% url 'origin-save' %}" class="nav-link swh-origin-save-link"> <i style="color: #e20026;" class="nav-icon mdi mdi-24px mdi-camera"></i> <p>Save code now</p> </a> </li> <li class="nav-item swh-help-item" title="How to browse the archive ?"> <a href="{% url 'browse-help' %}" class="nav-link swh-help-link"> <i style="color: #e20026;" class="nav-icon mdi mdi-24px mdi-help-circle"></i> <p>Help</p> </a> </li> {% if user.is_authenticated and user.is_staff %} <li class="nav-header">Administration</li> <li class="nav-item swh-origin-save-admin-item" title="Save code now administration"> <a href="{% url 'admin-origin-save' %}" class="nav-link swh-origin-save-admin-link"> <i style="color: #fecd1b;" class="nav-icon mdi mdi-24px mdi-camera"></i> <p>Save code now</p> </a> </li> <li class="nav-item swh-deposit-admin-item" title="Deposit administration"> <a href="{% url 'admin-deposit' %}" class="nav-link swh-deposit-admin-link"> <i style="color: #fecd1b;" class="nav-icon mdi mdi-24px mdi-folder-upload"></i> <p>Deposit</p> </a> </li> {% endif %} </ul> </nav> </div> </aside> <div class="content-wrapper"> <section class="content"> <div class="container" id="swh-web-content"> {% if swh_web_staging %} - <div class="swh-corner-ribbon">Staging</div> + <div class="swh-corner-ribbon">Staging<br/>v{{ swh_web_version }}</div> + {% elif swh_web_dev %} + <div class="swh-corner-ribbon">Development<br/>v{{ swh_web_version|split:"+"|first }}</div> {% endif %} {% block content %}{% endblock %} </div> </section> </div> {% include "includes/global-modals.html" %} <footer class="footer"> <div class="container text-center"> <a href="https://www.softwareheritage.org">Software Heritage</a> — Copyright (C) 2015–{% now "Y" %}, The Software Heritage developers. License: <a href="https://www.gnu.org/licenses/agpl.html">GNU - AGPLv3+</a>. <br /> The source code of Software Heritage <em>itself</em> + AGPLv3+</a>. <br/> The source code of Software Heritage <em>itself</em> is available on our <a href="https://forge.softwareheritage.org/">development - forge</a>. <br /> The source code files <em>archived</em> by Software - Heritage are available under their own copyright and licenses. <br /> + forge</a>. <br/> The source code files <em>archived</em> by Software + Heritage are available under their own copyright and licenses. <br/> <span class="link-color">Terms of use: </span> <a href="https://www.softwareheritage.org/legal/bulk-access-terms-of-use/">Archive access</a>, <a href="https://www.softwareheritage.org/legal/api-terms-of-use/">API</a>- <a href="https://www.softwareheritage.org/contact/">Contact</a>- <a href="{% url 'jslicenses' %}" rel="jslicense">JavaScript license information</a>- - <a href="{% url 'api-1-homepage' %}">Web API</a> + <a href="{% url 'api-1-homepage' %}">Web API</a><br/> + swh-web v{{ swh_web_version }} </div> </footer> <div id="back-to-top"> <a href="#top"><img alt="back to top" src="{% static 'img/arrow-up-small.png' %}" /></a> </div> <script> swh.webapp.setContainerFullWidth(); var statusServerURL = {{ status.server_url|jsonify }}; var statusJsonPath = {{ status.json_path|jsonify }}; swh.webapp.initStatusWidget(statusServerURL + statusJsonPath); </script> </body> </html> diff --git a/swh/web/tests/test_templates.py b/swh/web/tests/test_templates.py index 44986bf1..13178871 100644 --- a/swh/web/tests/test_templates.py +++ b/swh/web/tests/test_templates.py @@ -1,43 +1,63 @@ # Copyright (C) 2021 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information from copy import deepcopy import random +from pkg_resources import get_distribution + from swh.web.common.utils import reverse from swh.web.config import STAGING_SERVER_NAMES, get_config from swh.web.tests.django_asserts import assert_contains, assert_not_contains from swh.web.tests.utils import check_http_get_response +swh_web_version = get_distribution("swh.web").version + -def test_layout_without_staging_ribbon(client): +def test_layout_without_ribbon(client): url = reverse("swh-web-homepage") resp = check_http_get_response(client, url, status_code=200) assert_not_contains(resp, "swh-corner-ribbon") def test_layout_with_staging_ribbon(client): url = reverse("swh-web-homepage") resp = check_http_get_response( client, url, status_code=200, server_name=random.choice(STAGING_SERVER_NAMES), ) assert_contains(resp, "swh-corner-ribbon") + assert_contains(resp, f"Staging<br/>v{swh_web_version}") + + +def test_layout_with_development_ribbon(client): + url = reverse("swh-web-homepage") + resp = check_http_get_response( + client, url, status_code=200, server_name="localhost", + ) + assert_contains(resp, "swh-corner-ribbon") + assert_contains(resp, f"Development<br/>v{swh_web_version.split('+')[0]}") def test_layout_with_oidc_auth_enabled(client): url = reverse("swh-web-homepage") resp = check_http_get_response(client, url, status_code=200) assert_contains(resp, reverse("oidc-login")) def test_layout_without_oidc_auth_enabled(client, mocker): config = deepcopy(get_config()) config["keycloak"]["server_url"] = "" mock_get_config = mocker.patch("swh.web.common.utils.get_config") mock_get_config.return_value = config url = reverse("swh-web-homepage") resp = check_http_get_response(client, url, status_code=200) assert_contains(resp, reverse("login")) + + +def test_layout_swh_web_version_number_display(client): + url = reverse("swh-web-homepage") + resp = check_http_get_response(client, url, status_code=200) + assert_contains(resp, f"swh-web v{swh_web_version}")